Зачетный проект Яндекс Практикума по блоку тем «Анализ бизнес-показателей», «Событийная аналитика», «Принятие решений в бизнесе»
Цель: определить, влияет ли изменение шрифтов на поведение пользователей приложения.
Данные: лог с действиями пользователя и событиями.
Заказчик: команда стартапа.
Задачи:
import math as mth
import pandas as pd
import datetime as dt
import scipy.stats as stats
from matplotlib import pyplot as plt
from plotly import graph_objects as go
pd.set_option('mode.chained_assignment', None)
Откроем датасет и сохраним данные в переменной.
data = pd.read_csv('logs_exp.csv', sep='\t')
display(data.head())
data.info()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
Проверим данные на наличие пропусков.
data.isna().sum()
EventName 0 DeviceIDHash 0 EventTimestamp 0 ExpId 0 dtype: int64
Проверим данные на наличие дубликатов.
print('Количество дубликатов:', data.duplicated().sum())
Количество дубликатов: 413
Дубликаты в данных есть. Проверим, какую часть они составляют.
print('Относительная потеря данных при удалении дубликатов: {0:.2%}'.format(data.duplicated().sum() / len(data)))
Относительная потеря данных при удалении дубликатов: 0.17%
Доля составляет менее 0,2%. Удалим дубликаты.
data = data.drop_duplicates()
Передадим столбцам более короткие названия.
data.columns = ['event', 'id', 'timestamp', 'group']
Добавим новые столбцы: с датой и временем, с датой.
data['datetime'] = pd.to_datetime(data['timestamp'], unit='s')
data['date'] = data['datetime'].dt.date
Посмотрим на получившуюся таблицу.
display(data.head())
data.info()
| event | id | timestamp | group | datetime | date | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 | 2019-07-25 04:43:36 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 | 2019-07-25 11:11:42 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 | 2019-07-25 11:48:42 | 2019-07-25 |
<class 'pandas.core.frame.DataFrame'> Int64Index: 243713 entries, 0 to 244125 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event 243713 non-null object 1 id 243713 non-null int64 2 timestamp 243713 non-null int64 3 group 243713 non-null int64 4 datetime 243713 non-null datetime64[ns] 5 date 243713 non-null object dtypes: datetime64[ns](1), int64(3), object(2) memory usage: 13.0+ MB
Данные сохранены в переменной, пропусков не обнаружено, дубликаты удалены. Добавлены новые столбцы с датой и временем, а также отдельно с датой.
Посмотрим, какие события зафиксированы в логе и сколько в логе уникальных пользователей.
display(data['event'].unique())
print('Всего видов событий:', len(data['event'].unique()))
print('Уникальных пользователей:', len(data['id'].unique()))
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear',
'OffersScreenAppear', 'Tutorial'], dtype=object)
Всего видов событий: 5 Уникальных пользователей: 7551
Посчитаем среднее количество событий на пользователя. Для этого вначале определим, есть ли выбросы в данных. Построим графики.
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
plt.suptitle('Распределение количества событий на пользователя')
data.groupby('id').agg({'event': 'count'}).plot(kind='box', ax=ax[0], ylabel='общее число событий')
data.groupby('id').agg({'event': 'nunique'}).plot(kind='box', ax=ax[1], ylabel='число уникальных событий');
В общем количестве событий на пользователя есть значительные выбросы. В этом случае лучше возьмем медиану. В количестве уникальных событий на пользователя выбросов нет, можно использовать среднее, эта метрика будет более точной.
print('Медианное количество событий на пользователя:',
round(data.groupby('id').agg({'event': 'count'}).median()['event'], 2))
print('Среднее количество уникальных событий на пользователя:',
round(data.groupby('id').agg({'event': 'nunique'}).mean()['event'], 2))
Медианное количество событий на пользователя: 20.0 Среднее количество уникальных событий на пользователя: 2.67
Найдем максимальную и минимальную даты в логе.
print('Начало наблюдений:', data['date'].min())
print('Окончание наблюдений:', data['date'].max())
Начало наблюдений: 2019-07-25 Окончание наблюдений: 2019-08-07
Построим гистограмму по дате и времени.
data['datetime'].hist(figsize=(12, 3), bins=200)
plt.title('Распределение данных по дате и времени')
plt.ylabel('кол-во событий')
plt.xlabel('дата')
plt.xticks(list(data['date'].unique()), rotation=20);
Данные до 1 августа очевидно не полные, поэтому их не нужно брать для анализа. Остается определить границу по времени между датами. Предположим, что если мы возьмем время окончания наблюдений и отсчитаем определенное количество полных суток в обратную сторону, то получим полный датасет.
end = data['datetime'].max().to_pydatetime()
print('Окончание наблюдений:', end)
Окончание наблюдений: 2019-08-07 21:15:17
Итак, видим, что для получения 7 полных суток нужно взять данные с 9 вечера 31 июля. Другим решением будет провести границу по полуночи 1 августа. Построим гистограмму в границах, определенных по первому варианту.
start = end - dt.timedelta(days=7)
data.loc[data['datetime'] > start]['datetime'].hist(figsize=(12, 3), bins=200)
plt.title('Распределение данных по дате и времени в актуальный период')
plt.ylabel('кол-во событий')
plt.xlabel('дата');
По полученному графику видим, что за 3 часа от 31 июля у нас действительно имеется значимое количество данных и в выбранный период попадает первый всплеск данных, видимый на предыдущем графике. Принимаем решение взять данные за 7 полных суток.
dt = data.loc[data['datetime'] > start]
dt.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 241657 entries, 2057 to 244125 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event 241657 non-null object 1 id 241657 non-null int64 2 timestamp 241657 non-null int64 3 group 241657 non-null int64 4 datetime 241657 non-null datetime64[ns] 5 date 241657 non-null object dtypes: datetime64[ns](1), int64(3), object(2) memory usage: 12.9+ MB
Проверим, какое количество событий и пользователей мы теряем, отбросив старые данные.
print(
'Количество потерянных уникальных пользователей:',
len(data['id'].unique()) - len(dt['id'].unique()),
'\n',
'Относительная потеря данных: {0:.2%}'
.format((len(data['id'].unique()) - len(dt['id'].unique())) / len(data['id'].unique())),
'\n'
)
print('Количество потерянных событий:',
len(data['event']) - len(dt['event']),
'\n',
'Относительная потеря данных: {0:.2%}'
.format((len(data['event']) - len(dt['event'])) / len(data['event']))
)
Количество потерянных уникальных пользователей: 13 Относительная потеря данных: 0.17% Количество потерянных событий: 2056 Относительная потеря данных: 0.84%
Относительные потери данных незначительны. Посмотрим, как изменилось среднее количество событий на пользователя.
print('Медианное количество событий на пользователя:',
round(dt.groupby('id').agg({'event': 'count'}).median()['event'], 2))
print('Среднее количество уникальных событий на пользователя:',
round(dt.groupby('id').agg({'event': 'nunique'}).mean()['event'], 2))
Медианное количество событий на пользователя: 19.0 Среднее количество уникальных событий на пользователя: 2.67
Среднее и медианное количество событий почти не изменилось.
Проверим, что в данных за актуальный период попали пользователи из всех трёх экспериментальных групп.
groups = dt.groupby('group').agg(count=('timestamp', 'count'))
groups['share'] = round(groups['count'] / len(dt), 2)
groups
| count | share | |
|---|---|---|
| group | ||
| 246 | 79533 | 0.33 |
| 247 | 77277 | 0.32 |
| 248 | 84847 | 0.35 |
Всего в датасете уникальных видов событий — 5, в среднем приходится по 2,67 уникальных события на пользователя.
Определены границы актуального периода: с 9 вечера 31 июля до 9 вечера 8 августа.
После отсечения старых данных относительные потери в данных составляют меньше 1%.
В данных за актуальный период присутствуют пользователи из всех трех экспериментальных групп, объемы групп сопоставимы по размеру.
Посмотрим на общую частоту событий в датасете.
dt.groupby('event')['id'].count().sort_values(ascending=False)
event MainScreenAppear 117853 OffersScreenAppear 46514 CartScreenAppear 42336 PaymentScreenSuccessful 33945 Tutorial 1009 Name: id, dtype: int64
Посчитаем, сколько уникальных пользователей совершали каждое из этих событий и посчитаем долю пользователей, которые хоть раз совершали событие.
(dt
.groupby('event')
.agg(users=('id', 'nunique'), share=('id', lambda x: x.nunique() / dt['id'].nunique()))
.reset_index().sort_values('users', ascending=False)
)
| event | users | share | |
|---|---|---|---|
| 1 | MainScreenAppear | 7423 | 0.984744 |
| 2 | OffersScreenAppear | 4596 | 0.609711 |
| 0 | CartScreenAppear | 3736 | 0.495622 |
| 3 | PaymentScreenSuccessful | 3540 | 0.469621 |
| 4 | Tutorial | 843 | 0.111833 |
В целом частота событий распределяется согласно логике воронки: главный экран — экран товара — ввод данных карты — успешность проведения оплаты. Однако тот факт, что доля уникальных пользователей в первом событии в этой цепочке не 100%, говорит о том, что часть пользователей начинала с других шагов. Вероятно, в приложении есть возможность начать действия сразу со страницы товара, перейдя по прямой ссылке.
Под Tutorial, скорее всего, подразумевается обучение использованию приложения. Решим, что так, тогда это событие не является звеном последовательной цепочки. Исключим логи с ним для последующего анализа воронки.
fun = dt.loc[dt['event'] != 'Tutorial']
(fun
.groupby('event')
.agg(users=('id', 'nunique'), share=('id', lambda x: x.nunique() / fun['id'].nunique()))
.reset_index().sort_values('users', ascending=False)
)
| event | users | share | |
|---|---|---|---|
| 1 | MainScreenAppear | 7423 | 0.985267 |
| 2 | OffersScreenAppear | 4596 | 0.610035 |
| 0 | CartScreenAppear | 3736 | 0.495885 |
| 3 | PaymentScreenSuccessful | 3540 | 0.469870 |
Оценим объем потери данных после исключения логов с Tutorial.
print(
'Количество потерянных уникальных пользователей:',
len(dt['id'].unique()) - len(dt.loc[dt['event'] != 'Tutorial']['id'].unique()),
'\n',
'Относительная потеря данных: {0:.2%}'
.format((len(dt['id'].unique()) - len(dt.loc[dt['event'] != 'Tutorial']['id'].unique())) / len(dt['id'].unique())),
'\n'
)
print('Количество потерянных логов с событиями:',
len(dt['event']) - len(dt.loc[dt['event'] != 'Tutorial']['event']),
'\n',
'Относительная потеря данных: {0:.2%}'
.format((len(dt['event']) - len(dt.loc[dt['event'] != 'Tutorial']['event'])) / len(dt['event']))
)
Количество потерянных уникальных пользователей: 4 Относительная потеря данных: 0.05% Количество потерянных логов с событиями: 1009 Относительная потеря данных: 0.42%
Относительные потери данных незначительны.
По обновленному датасету посчитаем, какая доля пользователей переходит на каждый следующий шаг воронки.
steps = fun.groupby('event').agg(users=('id', 'nunique')).sort_values(by='users', ascending=False)
steps['step_share'] = steps['users'] / steps['users'].shift(1)
steps
| users | step_share | |
|---|---|---|
| event | ||
| MainScreenAppear | 7423 | NaN |
| OffersScreenAppear | 4596 | 0.619157 |
| CartScreenAppear | 3736 | 0.812881 |
| PaymentScreenSuccessful | 3540 | 0.947537 |
По полученной таблице видим, что наибольшее количество пользователей теряется на втором шаге. Доли тех, кто перешел от страницы товара к вводу данных карты и далее увидел экран успешной оплаты — значительно выше.
funnel = (
fun
.groupby('event')
.agg(users=('id', 'nunique'), share=('id', lambda x: x.nunique() / fun['id'].nunique()))
.reset_index().sort_values('users', ascending=False)
)
fig = go.Figure(go.Funnel(
y = list(funnel['event']),
x = list(funnel['users']),
textinfo = 'value+percent initial+percent previous')
)
fig.update_layout(title='Доля перехода пользователей на каждый следующий шаг воронки', title_x = 0.5)
fig.show()
Цепочка событий в воронке выглядит следующим образом: главный экран — экран товара — ввод данных карты — успешность проведения оплаты.
Событие Tutorial не входит в цепочку.
После удаления логов с событием Tutorial относительные потери данных составляют меньше 0,5%.
Больше всего пользователей теряется на переходе ко второму шагу — от главного экрана к странице товара. Однако есть еще потери, на которые следует обратить внимание: от ввода данных карты до экрана успешной оплаты переходят около 95% пользователей. Необходимо проверить в последующем, не связано ли это с техническими проблемами.
От первого события до оплаты доходит 48% уникальных пользователей.
Переименуем группы для удобства работы.
fun['group'] = fun['group'].replace([246, 247, 248], ['A1', 'A2', 'B'])
Проверим, не пересекаются ли пользователи в группах.
group_a1 = list(fun.query('group == "A1"')['id'])
group_a2 = list(fun.query('group == "A2"')['id'])
print('Пользователей из группы A1 в других группах:',
len(fun.query('group != "A1"').query('id.isin(@group_a1)')['id'].unique()))
print('Пользователей из группы A2 в других группах:',
len(fun.query('group != "A2"').query('id.isin(@group_a2)')['id'].unique()))
Пользователей из группы A1 в других группах: 0 Пользователей из группы A2 в других группах: 0
Посчитаем количество пользователей в каждой экспериментальной группе.
group_size = fun.groupby('group').agg(users=('id', 'nunique'))
group_size
| users | |
|---|---|
| group | |
| A1 | 2483 |
| A2 | 2516 |
| B | 2535 |
Посчитаем количество пользователей в каждой группе на каждом шаге.
test = (
fun
.pivot_table(index='event', columns='group', values='id', aggfunc='nunique')
.sort_values(by='B', ascending=False)
)
test
| group | A1 | A2 | B |
|---|---|---|---|
| event | |||
| MainScreenAppear | 2450 | 2479 | 2494 |
| OffersScreenAppear | 1542 | 1523 | 1531 |
| CartScreenAppear | 1266 | 1239 | 1231 |
| PaymentScreenSuccessful | 1200 | 1158 | 1182 |
Напишем функцию для проведения z-теста.
def z_test(event_1, event_2, total_1, total_2):
p1 = event_1 / total_1
p2 = event_2 / total_2
p_combined = (event_1 + event_2) / (total_1 + total_2)
diff = p1 - p2
z_value = diff / mth.sqrt(p_combined * (1 - p_combined) * (1/total_1 + 1/total_2))
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
return p_value
Проверим, находят ли статистические критерии разницу между контрольными группами A1 и A2 на каждом шаге. Используем z-тест пропорций.
Гипотезы для каждого из шагов:
H_0: Конверсия в группах А1 и А2 одинакова.
H_a: Конверсия в группах А1 и А2 не одинакова.
Установим параметр alpha — 0,05.
Поскольку на одних данных проводится несколько сравнений, мы имеем дело с множественным тестом: с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода. С учетом этого применим поправку Холма.
alpha = 0.05
quantity = 4
for i in range(len(test)):
p_value = z_test(test.iloc[i, 0], test.iloc[i, 1], group_size.iloc[0], group_size.iloc[1])
print(f'Событие {test.index[i]}, p-значение: {p_value}')
print('Уровень значимости:', alpha / quantity)
if p_value < alpha / quantity:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print('\n')
quantity -= 1
Событие MainScreenAppear, p-значение: [0.67020827] Уровень значимости: 0.0125 Не получилось отвергнуть нулевую гипотезу Событие OffersScreenAppear, p-значение: [0.25455454] Уровень значимости: 0.016666666666666666 Не получилось отвергнуть нулевую гипотезу Событие CartScreenAppear, p-значение: [0.21811884] Уровень значимости: 0.025 Не получилось отвергнуть нулевую гипотезу Событие PaymentScreenSuccessful, p-значение: [0.10288527] Уровень значимости: 0.05 Не получилось отвергнуть нулевую гипотезу
P-значение в каждом из случаев значительно превышает alpha (превышало бы даже без поправки), что позволяет сделать вывод о том, что по статистическим критериям разницы между контрольными группами нет. Можно утверждать, что разбиение на группы проведено корректно.
Подобным образом проведем проверку для экспериментальной группы B (с измененным шрифтом) по каждому событию:
1) с каждой из контрольных групп в отдельности (8 сравнений),
2) с объединенной контрольной группой (4 сравнения).
Дополним датасет новыми столбцами с данными для объединенной контрольной группы, а также столбцами с размерами групп.
test.insert(2, 'AA', test['A1'] + test['A2'])
test['A1_size'] = group_size.iloc[0, 0]
test['A2_size'] = group_size.iloc[1, 0]
test['AA_size'] = test['A1_size'] + test['A2_size']
test['B_size'] = group_size.iloc[2, 0]
test
| group | A1 | A2 | AA | B | A1_size | A2_size | AA_size | B_size |
|---|---|---|---|---|---|---|---|---|
| event | ||||||||
| MainScreenAppear | 2450 | 2479 | 4929 | 2494 | 2483 | 2516 | 4999 | 2535 |
| OffersScreenAppear | 1542 | 1523 | 3065 | 1531 | 2483 | 2516 | 4999 | 2535 |
| CartScreenAppear | 1266 | 1239 | 2505 | 1231 | 2483 | 2516 | 4999 | 2535 |
| PaymentScreenSuccessful | 1200 | 1158 | 2358 | 1182 | 2483 | 2516 | 4999 | 2535 |
Аналогично применим z-тест для сравнения долей в каждом из 12 случаев. Гипотезы для каждого из шагов:
H_0: Конверсия в группах одинакова.
H_a: Конверсия в группах не одинакова.
Установим параметр alpha — 0,05. С учетом множественности теста также применим поправку Холма.
quantity = 12
for y in range(0, 3):
print(f'Сравнение групп {test.columns[y]} и {test.columns[3]}', '\n')
for i in range(len(test)):
p_value = z_test(test.iloc[i, y], test.iloc[i, 3], test.iloc[i, y+4], test.iloc[i, 7])
print(f'Событие {test.index[i]}, p-значение: {p_value}')
print('Уровень значимости:', alpha / quantity)
if p_value < alpha / quantity:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print('\n')
quantity -= 1
Сравнение групп A1 и B Событие MainScreenAppear, p-значение: 0.396910049618151 Уровень значимости: 0.004166666666666667 Не получилось отвергнуть нулевую гипотезу Событие OffersScreenAppear, p-значение: 0.21442476639710506 Уровень значимости: 0.004545454545454546 Не получилось отвергнуть нулевую гипотезу Событие CartScreenAppear, p-значение: 0.08564271892834707 Уровень значимости: 0.005 Не получилось отвергнуть нулевую гипотезу Событие PaymentScreenSuccessful, p-значение: 0.22753674585530037 Уровень значимости: 0.005555555555555556 Не получилось отвергнуть нулевую гипотезу Сравнение групп A2 и B Событие MainScreenAppear, p-значение: 0.6723167704766229 Уровень значимости: 0.00625 Не получилось отвергнуть нулевую гипотезу Событие OffersScreenAppear, p-значение: 0.9200426006644042 Уровень значимости: 0.0071428571428571435 Не получилось отвергнуть нулевую гипотезу Событие CartScreenAppear, p-значение: 0.6264599792848009 Уровень значимости: 0.008333333333333333 Не получилось отвергнуть нулевую гипотезу Событие PaymentScreenSuccessful, p-значение: 0.6680367850275775 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу Сравнение групп AA и B Событие MainScreenAppear, p-значение: 0.4599468774918498 Уровень значимости: 0.0125 Не получилось отвергнуть нулевую гипотезу Событие OffersScreenAppear, p-значение: 0.4402711073657435 Уровень значимости: 0.016666666666666666 Не получилось отвергнуть нулевую гипотезу Событие CartScreenAppear, p-значение: 0.20361356481451098 Уровень значимости: 0.025 Не получилось отвергнуть нулевую гипотезу Событие PaymentScreenSuccessful, p-значение: 0.6559128929243401 Уровень значимости: 0.05 Не получилось отвергнуть нулевую гипотезу
Ни в одном из случаев сравнения — группы A1 и B, группы A2 и B, группы AA объединенная и B — и ни на одном из шагов не удалось обнаружить статистически значимой разницы между долями. Во всех случаях значение p-value не приближается к пороговому значению alpha даже без поправки.
Пересечения пользователей во всех трех группах не выявлено. Общие размеры всех трех групп сопоставимы.
Между контрольными группами A1 и A2 разницы по статистическим критериям не выявлено. Можно утверждать, что разбиение на группы проведено корректно.
Для экспериментальной группы B не выявлено разницы по статистическим критериям ни в одном из случаев сравнения:
Подводим итог эксперимента: изменение шрифтов в приложении не влияет на поведение пользователей.